Skip to content

Emit the LM head weight gradient in kernel order under explicit sharding - #5091

Draft
NuojCheng wants to merge 9 commits into
mainfrom
chengnuojin-explicit-lmhead-orientation
Draft

Emit the LM head weight gradient in kernel order under explicit sharding#5091
NuojCheng wants to merge 9 commits into
mainfrom
chengnuojin-explicit-lmhead-orientation

Conversation

@NuojCheng

@NuojCheng NuojCheng commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

What

Under shard_mode: explicit, MaxText pays a step-time penalty that traces to a single unfoldable transpose on the LM head's weight gradient. This adds lm_head_weight_grad_in_kernel_order (default off), which removes it, and drops the previously-proposed lm_head_kernel_transposed in favour of it.

Why

JAX's lower_with_sharding_in_types annotates every sharding-in-types primitive's output with a custom_call_target="Sharding" op — 65 of them under auto on llama2 versus 1,276 under explicit. One lands between the LM head weight-gradient dot and the transpose that follows it, and XLA's algebraic simplifier will not fold transpose(dot(A,B)) → dot(B,A) across it. Census on the dp4 module: auto has 17 adjacent (foldable) transpose-of-dot pairs, explicit has 0.

The transpose survives to codegen and costs twice:

  1. A lost reduce-scatter. The gradient is left sharded on its minor-most dimension, below the ~512-byte per-shard extent tpu-all-reduce-scatter-fusion requires, so the weight-gradient all-reduce is never rewritten into a reduce-scatter — 2× the wire bytes for that tensor.
  2. Six relayout copies per step. The resulting layout propagates through the whole Adam update, so the donated optimizer state must be converted in and out — 196.6 MB/step on llama2, up to 1.57 GB/step at emb 4096.

The barrier itself cannot be removed: it is JAX's, not MaxText's. Gating MaxText's out_sharding pin produces a 0-line HLO diff.

How

DenseGeneral gains weight_grad_in_kernel_order, backed by a custom_vjp whose backward pass contracts dk straight into the kernel's stored axis order — so there is no transpose to fold in the first place. The stored kernel, its sharding and its initialization are untouched, which makes this checkpoint-compatible; the gradient differs from the default rule only by floating-point reassociation.

It self-disables under shard_mode: auto (where XLA folds the transpose on its own) and under quantization (where it cannot own the dot). logits_via_embedding is rejected rather than silently ignored. The untied LM head opts in via lm_head_weight_grad_in_kernel_order on both the linen and nnx decoder paths.

Measured

v5p, 4 chips, xprof train_step_ns median on /device:TPU:0, 56 runs. All arms run back-to-back per config so within-row deltas carry no session drift.

config auto µs explicit µs +kernel-order µs explicit−auto flag−auto rep spread
deepseek 9081.0 9883.5 9216.8 +8.84% +1.50% 0.56%
llama2 4027.4 4223.8 3995.3 +4.88% −0.80% 0.79%
mistral 3905.2 4084.6 3853.0 +4.59% −1.34% 0.97%
mixtral 10104.5 10407.2 10129.8 +3.00% +0.25% 0.12%
qwen3 8261.8 8608.6 8214.8 +4.20% −0.57% 0.51%
w2048 14972.1 15215.1 14886.3 +1.62% −0.57% 0.04%
prod 196039.4 196558.5 196101.9 +0.26% +0.03% 0.03%

Please read the two halves of that table differently — this is the part that is easy to over-claim.

The five emb 512 rows go from a +4.59% median penalty to −0.57%, beating auto outright on three of five. But emb 512 / fsdp 4 puts 256 bytes per shard on the minor-most dimension, below XLA's 512-byte AR→RS gate, so mechanism (1) is active only there. No production job has that geometry — those rows are an artifact of the shrunk benchmark proxies.

The two bold rows are the real ship criterion, and the bar there is no regression, not a win: prod (emb 2048, 16 layers, seq 4k) is +0.03% against a 0.03% rep spread, w2048 is −0.57%. There was almost nothing left to recover at that geometry, and recovering it costs nothing.

Acceptance

On llama2/shrink/fsdp4 the explicit dump gains %all-reduce-scatter.2, byte-for-byte identical to auto's %all-reduce-scatter.5bf16[512,32000] → bf16[128,32000], layout {1,0:T(8,128)(2,1)}, dim-0 dynamic-slice. f32[emb/n, 32000] relayout copies in after_optimizations drop from 6 to 0, matching auto.

Numerical equivalence

20 training steps against the auto baseline, same seed, max absolute loss deviation:

config explicit explicit + flag
llama2 2.62e-04 2.52e-04
mixtral 1.41e-04 9.63e-05

The flag sits at explicit's own noise level — on mixtral slightly below it — which is what reassociation alone predicts.

Changed since the first revision of this PR

lm_head_kernel_transposed has been removed. That flag stored the kernel as [vocab, embed] to reach the same layout, and it did work. It is dropped because the custom_vjp dominates it on every axis measured:

  • Slower on four of seven configs by 1.85–4.02%, because it recovers only the relayout copies and not the reduce-scatter, and within noise on the two where it already won.
  • No checkpoint-conversion path — it changes the on-disk shape of params-decoder-logits_dense-kernel, so it cannot be flipped on an existing run.
  • Changes initialization. At the flipped shape jax.random draws a different matrix from the same seed, so it perturbs the loss ~35× more than explicit's own noise (llama2 9.42e-03 vs 2.52e-04) — reinitialization, not rounding.

Shipping a strictly-dominated flag that also carries a silent checkpoint hazard seemed worse than shipping one flag that works, so the transposed-kernel code, its config plumbing, its integration/tunix and vLLM converter changes, and its tests are all gone. Happy to restore it if you would rather have both.

A second negative result is documented rather than shipped. Eliding redundant reshard barriers (no-op reshards where the aval already carries the requested sharding) looked like the obvious companion fix. It was implemented and measured: it removes ~4% of barriers in a real module and the optimized executable is op-identical (llama2 2889→2889, prod 2942→2942, deepseek 9642→9642, zero differing ops). XLA already discards no-op Sharding custom-calls. Recorded in the doc so the next person does not spend the week.

Testing

  • tests/unit/linears_test.py — 7 new tests: kernel bit-identity, forward equality, gradient equality across three contraction shapes, the LM-head 2-D shape in fp32 and bf16, composition with nnx.remat, the auto-mode no-op, and the quantization yield. Whole file passes (21 passed, 1 TPU-only skipped).
  • tests/unit/pyconfig_test.py, tests/unit/configs_value_test.py — pass.
  • End-to-end train on v5p, 6 steps, flag off vs on: loss identical to 3 decimals at every step.
  • pyink clean, pylint clean (the two remaining findings in types.py pre-exist on main).

Notes

  • Default stays false. The flag is inert under auto and checkpoint-safe under explicit, so flipping the default is a follow-up decision, not a prerequisite.
  • Full analysis, including the mechanism, the causal controls, and both negative results, is in docs/guides/optimization/shard_mode_performance.md.
  • The largest remaining lever measured but not in this PR: extending the same custom_vjp to the in-loop DenseGenerals, gated on a leading FSDP axis. Estimated +360.7 µs at prod geometry, but it is untested under nnx.scan + remat and is roughly a week of work.

Follow-up (2026-09-03)

Four commits pushed on top of the above. Two of them close the last items this PR left open — the default, and the in-loop DenseGenerals — and two are separate wins found while measuring. Both "Notes" bullets above are superseded: the default is now on, and the in-loop lever is in this PR.

The one table

Eight onboarded models at emb 2048 / mlp 8192 / 16 layers (deepseek 8, gemma3 18 — it scans in groups of 6), seq 1024, pdbs 1, fsdp 4 on v5p-8. One build, 54 runs, 3 reps per arm, medians. The explicit column has nothing in the config but shard_mode: explicit — it is what a user gets.

model tied auto ns explicit ns Δ rep spread A/B + dense flag
mistral-7b no 69,990,530 69,604,164 −0.552% 0.01% / 0.04%
qwen3-8b no 83,941,679 83,490,576 −0.537% 0.08% / 0.13%
llama2-7b no 70,094,537 69,748,502 −0.494% 0.03% / 0.02%
gemma2-2b yes 171,538,409 170,719,766 −0.477% 0.05% / 0.04%
gemma-2b yes 88,434,470 88,272,771 −0.183% 0.01% / 0.01%
mixtral-8x7b no 353,006,821 353,151,437 +0.041% 0.03% / 0.07%
deepseek3-16b (L8) no 230,885,239 231,292,724 +0.176% 0.02% / 0.02% +0.021%
gemma3-4b (L18) yes 88,235,432 88,944,184 +0.803% 0.28% / 0.05% −0.101%

Median −0.330%, faster on five of eight and inside the rep spread on a sixth. With the two per-model dense-flag settings applied, the worst regression anywhere is mixtral's +0.041%, which is inside mixtral's own 0.07% rep spread. Before this work the worst cases were qwen3 +3.25%, gemma3 +2.02% and deepseek +0.92%.

lm_head_weight_grad_in_kernel_order is now on by default where it can act

Re-measured on every model, at L12/L14/L16, and at vocab 4096 and 32000, the flag has never lost to auto — there is no measured configuration where off is the better choice, so off was the wrong default.

model (above gate) explicit explicit + flag
qwen3-8b +3.252% −0.405%
deepseek3-16b (L8) +0.920% +0.164%
llama2-7b −0.061% −0.468%
mistral-7b −0.186% −0.548%
mixtral-8x7b −1.393% +0.009%

Mixtral is the one model the flag does not help; its −1.393% is an MoE-scan scheduling windfall unrelated to the LM head (it oscillates +0.120% / −0.151% / −0.038% across L12/L14/L16, and shrinking the vocab 8× leaves the spread unchanged), and the flag costs it nothing — it lands on auto's schedule rather than losing to it.

The flag is therefore tri-state: None resolves to on for an untied head under shard_mode: explicit, off everywhere else. An explicit true on a tied head is now an error rather than silently ignored.

dense_weight_grad_in_kernel_order — the in-loop lever, shipped default-off

The same custom_vjp wired to the three MlpBlock, four attention and nine MLA/Indexer projections. It turned out not to be a one-signed missed optimization. Under explicit the Sharding custom-calls decouple the gradient's layout from the stored parameter's, so XLA picks the dot's preferred layout; the flag declines that freedom and reproduces auto's layout op for op. On the deep-scan models both modes relayout anyway and explicit's source is 24% cheaper to convert (20.2 vs 26.4 µs/copy), so the flag gives back a real win:

model default + dense flag
gemma3-L18 +0.690% −0.267% use it
deepseek +0.164% −0.003% use it
mixtral +0.009% +0.065% leave off
gemma2-2b −0.518% −0.327% leave off
gemma-2b −0.213% +0.165% leave off
llama2 −0.468% +0.013% leave off
mistral −0.548% −0.049% leave off
qwen3 −0.405% +0.192% leave off

The discriminator is the scanned gradient stack's depth — the two winners are 3 and 1 deep, the six losers all 16 — but two points below the line against six above is not enough to fit a default on, so it ships off, with the two models named in base.yml and in section 4.8 of the doc. Stored kernels and their initialization are untouched, so there is no checkpoint hazard, and it is inert under auto and on quantized layers.

Two wins that are not about sharding at all

YarnRotaryEmbedding built its whole frequency table inside every layer of every step to read max_target_length rows out of it — f32[163840, 32] on deepseek, 0.6% of it used. Computing the rows straight from position is −1.4% of step time, equally in both modes, which is the check that it is not a sharding effect. It survived a full mode-comparison sweep precisely because both modes paid it: A/B diffing two modes cannot find work both modes do. The detector that found it — flag any op whose output element count is ≥ 8× its largest input's — was then run over all eight models and found nothing else above ~0.06%.

attend_on_embedding materialized table.T. Under explicit that is a distinct typed value XLA cannot fold into the dot's dimension numbers, so the table shard was cast to bf16 twice and the tied head's gradient came out flipped. Expressed as einsum dimension numbers instead.

Also in the doc, not in the code

  • Section 6.3, explicit's largest and most stable win: explicit deletes 480 of 495 bf16[24,8192] collective-permute-start ops — the rotated-reduce-scatter fixup — on every untied model.
  • Thirteen refuted candidates, each implemented or measured rather than reasoned about, so the next person does not spend the time.
  • A systematic hunt for any remaining explicit-only cost on llama2/mistral/qwen3 above the gate found nothing above ~0.10% of step.

Testing (follow-up commits)

  • tests/unit/linears_test.py, tests/unit/embeddings_test.py, tests/unit/pyconfig_test.py — 72 passed, 1 TPU-only skipped, 26 subtests. New coverage: the custom_vjp on >2-D kernels via _permuted_sharding, freqs_cis_at bit-identical to indexing the full table at the bottom/middle/top of a 163,840-row table, attend_on_embedding forward and gradient equality across three query ranks, and the tri-state resolution including the tied-head rejection.
  • tests/unit/configs_test.py, tests/unit/configs_value_test.py — 113 passed, 110 subtests.
  • pyink, pylint, codespell, mdformat, yamllint clean via pre-commit (the two types.py pylint findings pre-exist on main).

Second follow-up (2026-09-03): the depth sweep that invalidated the tables above

Three commits: 5b9725702, 6a54684a7, 169aac772.

Everything above this line was measured at 4, 8 or 16 decoder layers. All three are scan depths where an unrelated XLA layout pathology inflates both sharding modes by ~25%, and a 25% inflation is more than enough scheduling noise to swamp — and repeatedly flip the sign of — a 0.4% sharding effect. The tables are kept and marked rather than deleted, because the corrections reverse conclusions that looked solid.

The pathology (doc section 4.9)

Walking llama2 from 2 to 24 layers at emb 2048 / mlp 8192, ns-per-layer is flat at ~3.3 M except at stack lengths 2, 4, 8, 16 and 24, where it jumps 25–34%:

L auto ns ns/layer L auto ns ns/layer
8 36,536,639 4,567,080 9 31,091,571 3,454,619
16 70,094,537 4,380,909 18 58,852,896 3,269,605
24 104,650,808 4,360,450 20 65,004,700 3,250,235

At those lengths XLA assigns layout {2,0,1} to the scanned parameter stack f32[L,512,8192] instead of {2,1,0}, so every dynamic-update-slice writes a degenerate T(1,128)-tiled slice — three op families costing 8.16 ms of a 36.5 ms step at L8, byte-identical in both sharding modes. param_scan_axis: 0 removes it (L8 −23.6%, L16 −24.9%) and costs 0.18–0.28% at healthy depths; it survives at production geometry (+14.1% at L8, +11.5% at L16). This is unrelated to shard_mode, is larger than everything else in the doc, and is not changed by this PR — it is documented as section 8 item 03 for someone to take on separately.

What that does to the two flags

Re-measured where the pathology does not fire, dense_weight_grad_in_kernel_order has one sign everywhere and the two flags are additive. llama2 / qwen3 at 18 layers, four ways:

flags llama2 qwen3
neither +1.098% +5.579%
dense only +0.621% +4.853%
LM-head only +0.429% +0.534%
both +0.005% +0.008%

Across 15 (model, depth) pairs the LM-head flag alone leaves a median +0.463% penalty against auto (worst +1.038%, gemma2); with both flags the median is +0.005% and the full range is −0.183% … +0.134%. The "two models only" recommendation in the section above was fitted entirely to contaminated points, so dense_weight_grad_in_kernel_order now defaults on under shard_mode: explicit, same tri-state shape as the LM-head flag.

The replacement table (doc section 5.6)

Eight models, d16 geometry, 3 reps, nothing in the config but shard_mode: explicit — both flags resolve themselves. Every depth chosen so the pathology is not firing:

model layers auto ns explicit ns Δ rep spread A/B
gemma-2b 18 78,786,920 78,247,099 −0.685% 0.08% / 0.06%
gemma3-4b 18 88,335,850 88,005,221 −0.374% 0.21% / 0.31%
mixtral-8x7b 14 309,329,429 309,088,916 −0.078% 0.08% / 0.07%
llama2-7b 18 58,858,748 58,857,796 −0.002% 0.02% / 0.04%
gemma2-2b 18 150,702,912 150,706,999 +0.003% 0.06% / 0.02%
mistral-7b 18 58,432,480 58,443,356 +0.019% 0.02% / 0.01%
qwen3-8b 18 72,851,689 72,914,405 +0.086% 0.18% / 0.24%
deepseek2-16b 12 360,257,035 360,735,487 +0.133% 0.16% / 0.05%

Median +0.000%; worst case +0.133% against a 0.16% rep spread on the same row. Six of eight rows sit inside their own rep spread in both directions; the two that do not are gemma-2b and gemma3-4b, and explicit wins both.

The three models this round set out to fix, before → after:

model LM-head flag only both flags, healthy depth
gemma3-4b +0.803% −0.374%
mixtral-8x7b +0.041% −0.078%
deepseek2-16b +0.176% +0.133%

gemma3 is where the dense flag does real work: 1.18 pp, and it changes the sign. mixtral and deepseek were already inside noise and still are; what the flag buys there is not a win but the absence of a tail.

mixtral and deepseek are at 14 and 12 layers because at 18 both exceed HBM at this geometry (deepseek asks 43.56 G of temporaries against 31.24 G available). Both are healthy stack lengths, which is the property that matters.

custom_vjpjax.sharding.auto_axes

Both flags are now implemented by tracing the forward dot inside a jax.sharding.auto_axes region rather than by hand-writing the reordered gradient. The region drops the sharding barrier for the length of that one dot, so Shardy re-propagates the operands' shardings exactly as shard_mode: auto would and XLA performs the fold itself:

def _dot_general_in_auto_axes(inputs, kernel, dimension_numbers, precision, out_sharding):
  dot = functools.partial(lax.dot_general, dimension_numbers=dimension_numbers, precision=precision)
  if out_sharding is None:
    out_sharding = jax.eval_shape(dot, inputs, kernel).sharding
    if out_sharding is None:  # no mesh -> no barrier to remove
      return dot(inputs, kernel)
  return jax.sharding.auto_axes(dot, out_sharding=out_sharding)(inputs, kernel)

114 fewer lines, gradients now bit-identical to the default rule rather than agreeing to rounding, no operand-permutation bookkeeping for >2-D kernels, and it works at all 17 call sites including MLA. Step times agree with the custom_vjp to within 0.15 pp on all five models measured both ways.

The unit test for the mechanism changed accordingly: auto_axes leaves the transpose in the jaxpr (XLA folds it later), so the test now asserts the barrier is gone — under explicit the weight-gradient transpose's operand is defined by stablehlo.dot_general with the flag on and by sdy.sharding_constraint with it off.

Disclosed, not fixed

  • mixtral prefers explicit with neither flag, by ~1.4% at L12/L14/L16. It reproduces, survives an 8× vocab shrink, and has the opposite sign on the other MoE model in the sweep. The shipped default gives that up to land on parity; lm_head_weight_grad_in_kernel_order: false is called out in the doc for mixtral users who can measure it.
  • param_scan_axis: 0 is not made a default here. It is worth ~25% at the affected depths but costs 0.18–0.28% elsewhere and has only been characterized on one model family and one chip generation.

Testing

  • tests/unit/linears_test.py, tests/unit/pyconfig_test.py — 60 passed, 1 TPU-only skipped, 20 subtests.
  • tests/unit/sharding_test.py, sharding_nnx_test.py, sharding_compare_test.py, sharding_desc_test.py, moe_test.py, attention_test.py — 190 passed, 126 skipped.
  • pyink, pylint (10.00/10 on both changed Python files), codespell, mdformat, yamllint clean via pre-commit.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a detailed performance analysis guide comparing explicit and automatic sharding modes, and implements a performance optimization via the lm_head_kernel_transposed configuration. This option allows storing the untied LM head kernel as [vocab, embed] instead of [embed, vocab], which prevents an expensive transpose operation under explicit sharding. The changes update DenseGeneral to support the transposed kernel layout, add corresponding configuration validation, adjust integration mappings for Tunix and vLLM, and include comprehensive unit tests. The single review comment regarding a date typo in the documentation has been filtered out, leaving no further feedback to provide.

@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.30435% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/maxtext/layers/linears.py 83.33% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Under `shard_mode: explicit`, JAX's `lower_with_sharding_in_types` annotates
every sharding-in-types primitive's output with a `custom_call_target="Sharding"`
op. One of those lands between the LM head weight-gradient `dot` and the
`transpose` that follows it, and XLA's algebraic simplifier will not fold
`transpose(dot(A, B))` into `dot(B, A)` across it. Census on the dp4 module:
auto has 17 adjacent (foldable) transpose-of-dot pairs, explicit has 0. The
transpose survives to codegen, which costs twice -- the gradient is left sharded
on its minor-most dimension, below the 512-byte extent
`tpu-all-reduce-scatter-fusion` requires, so the weight-gradient all-reduce is
never rewritten into a reduce-scatter; and the resulting layout propagates
through the Adam update, forcing six relayout copies on the entry parameters.

Remove the need for the fold rather than the barrier. `DenseGeneral` gains a
`weight_grad_in_kernel_order` option backed by a `custom_vjp` whose backward pass
contracts `dk` straight into the kernel's stored axis order, so no transpose is
emitted at all. The stored kernel, its sharding and its initialization are
untouched, so this is checkpoint-compatible; the gradient differs from the
default rule only by floating-point reassociation. It self-disables under
`shard_mode: auto`, where XLA folds the transpose on its own, and under
quantization, where it cannot own the dot. The untied LM head opts in via
`lm_head_weight_grad_in_kernel_order` (default false) on both the linen and nnx
decoder paths.

Measured on v5p, 4 chips, xprof `train_step_ns` median on `/device:TPU:0`,
56 runs, arms run back-to-back per config so within-row deltas carry no session
drift:

  config     auto us  explicit  +kernel-order   B-A%    D-A%   rep spread
  deepseek    9081.0    9883.5         9216.8  +8.84   +1.50        0.56%
  llama2      4027.4    4223.8         3995.3  +4.88   -0.80        0.79%
  mistral     3905.2    4084.6         3853.0  +4.59   -1.34        0.97%
  mixtral    10104.5   10407.2        10129.8  +3.00   +0.25        0.12%
  qwen3       8261.8    8608.6         8214.8  +4.20   -0.57        0.51%
  w2048      14972.1   15215.1        14886.3  +1.62   -0.57        0.04%
  prod      196039.4  196558.5       196101.9  +0.26   +0.03        0.03%

Read the two halves differently. The five emb-512 rows go from a +4.59% median
penalty to -0.57%, but emb 512 / fsdp 4 puts 256 bytes per shard on the
minor-most dimension, below XLA's 512-byte AR->RS gate -- so those rows are a
benchmark artifact of the shrunk proxies and no production job has that geometry.
The ship criterion is the two emb-2048 rows, where the bar is no regression
rather than a win: prod +0.03% against a 0.03% rep spread, w2048 -0.57%.

Acceptance: on llama2/shrink/fsdp4 the explicit dump gains
`%all-reduce-scatter.2`, byte-for-byte identical to auto's
`%all-reduce-scatter.5` (`bf16[512,32000] -> bf16[128,32000]`, layout
`{1,0:T(8,128)(2,1)}`, dim-0 dynamic-slice), and `f32[emb/n, 32000]` relayout
copies in `after_optimizations` drop from 6 to 0.

Numerical equivalence over 20 steps against auto, max absolute loss deviation:
llama2 explicit 2.62e-04, with the flag 2.52e-04; mixtral 1.41e-04 and 9.63e-05.
The flag sits at explicit's own noise level, as reassociation alone predicts.

`logits_via_embedding` is rejected rather than silently ignored, since the flag
only applies to the untied head.

An earlier revision of this branch shipped `lm_head_kernel_transposed`, which
stores the kernel as `[vocab, embed]` to reach the same layout. It is dropped
here: it is 1.85-4.02% slower than the custom_vjp on four of seven configs
because it recovers only the relayout copies and not the reduce-scatter, it has
no checkpoint-conversion path, and at the flipped shape `jax.random` draws a
different matrix from the same seed, which perturbs the loss ~35x more than
explicit's own noise (llama2 9.42e-03 vs 2.52e-04).

Analysis, including the measured negative result for eliding redundant reshard
barriers, is in docs/guides/optimization/shard_mode_performance.md.
@NuojCheng
NuojCheng force-pushed the chengnuojin-explicit-lmhead-orientation branch from 6ff6d3c to 780e3e4 Compare September 2, 2026 16:00
@NuojCheng NuojCheng changed the title Remove the explicit-sharding LM head transpose via lm_head_kernel_transposed Emit the LM head weight gradient in kernel order under explicit sharding Sep 2, 2026
`attend_on_embedding` materialized `table.T` to compute the tied output head's
logits. Under `shard_mode: explicit` the transposed table is a distinct typed
value, so XLA cannot fold it into the dot's dimension numbers: the table shard
is cast to bf16 once for the input lookup in `Embed.__call__` and a second time
here, and the tied head's weight gradient comes out flipped and needs a
transpose to put back.

Expressing the transpose as dimension numbers via einsum keeps both consumers
on one cast and leaves the gradient in the table's own axis order. Under
`shard_mode: auto` XLA already folded the `.T` away, so this is a no-op there.
It reassociates the accumulation, so logits agree to float rounding rather than
bit for bit.
`YarnRotaryEmbedding.freqs_cis` built the whole
`[max_position_embeddings, half_dim]` table and then indexed
`max_target_length` rows out of it. The table is traced, not a constant XLA can
hoist, so it was rebuilt inside every layer of every step: on deepseek2-16b
that is `f32[163840, 32]` per layer per step, of which 0.6% is read.

Row `p` is `exp(1j * p * corrected_freqs)`, so the rows can be produced
straight from `position`. `freqs_cis_at` does that; `freqs_cis` stays as the
reference definition the new test checks against, and the two agree bit for
bit. Measured on deepseek at emb 2048 / mlp 8192 / L8 this is -1.4% of step
time, and the same -1.4% under `shard_mode: auto` and `explicit` alike.

`_cis` writes `exp(1j * theta)` as `cos(theta) + 1j*sin(theta)`. It is
bit-identical, but it avoids XLA's overflow-safe complex `exponential`
expansion, which evaluates `exp(real(1j*theta)) == exp(0)` over the whole
tensor -- twice under explicit sharding, where the `Sharding` custom-call on
the broadcast `1j` blocks the reassociation that would let CSE merge them.
The in-loop counterpart of `lm_head_weight_grad_in_kernel_order`: wire the same
`custom_vjp` to the three `MlpBlock` projections, the four attention
projections and the nine MLA / Indexer projections, so their weight gradients
are contracted straight into each kernel's stored axis order.

Unlike the LM-head flag this is a trade, not a fix, so it defaults off. Under
`shard_mode: explicit` the `Sharding` custom-calls decouple the gradient's
layout from the stored parameter's, leaving XLA free to pick it; the flag
declines that freedom and reproduces `shard_mode: auto`'s layout op for op.
Whether that is a win depends on the scanned gradient stack's depth. At emb
2048 / mlp 8192 it is worth 0.90pp on gemma3-4b and 0.16pp on
deepseek3-16b, whose stacks are 3 and 1 deep, and costs 0.06%-0.60% on the six
models whose stacks are 16 deep -- two points below the line against six above
is not enough to fit a default on, so it ships off with the two models named in
the docs.

`_permuted_sharding` handles the >2-D kernels the in-loop sites have; on a 2-D
kernel it degenerates to the identity, which is why the LM head never needed
it. Stored kernels and their initialization are untouched, so no checkpoint
conversion is needed, and the flag is inert under `shard_mode: auto` and on
quantized layers.

See docs/guides/optimization/shard_mode_performance.md section 4.8.
Re-measured across all eight onboarded models at emb 2048 / mlp 8192 / 16
layers -- above the geometry gate an emb-512 proxy sits below -- the flag has
never lost to `shard_mode: auto`: on any model, at any depth (L12/L14/L16), at
any vocab (4096 or 32000). Without it, explicit is +3.25% on qwen3-8b and
+0.92% on deepseek3-16b. There is no configuration measured where leaving it
off is the better choice, so off is the wrong default.

It is now tri-state. `None` resolves to on for an untied head under
`shard_mode: explicit` and off everywhere else, which is exactly where it can
do something: under `auto` XLA folds the transpose itself, and a tied head has
no kernel of its own. An explicit `true` on a tied head is now an error rather
than silently ignored.

With this default and nothing else in the config but `shard_mode: explicit`,
the median across the eight models is -0.330% and the worst case is gemma3 at
+0.803%; adding `dense_weight_grad_in_kernel_order` on the two models that want
it leaves no model regressing by more than 0.041%.
Re-measures the whole matrix at above-gate geometry with nothing written out
but `shard_mode` (section 5.4, the table to quote), documents the LM-head
flag's new default, adds section 4.8 on what the dense-layer flag actually
trades, section 5.5 on the YaRN table, and section 6.3 on the rotated
reduce-scatter -- explicit's largest and most stable win, 480 of 495
`collective-permute-start` ops deleted on every untied model.

Also records what did not work: thirteen candidates that were implemented or
measured and refuted, and a systematic hunt for any remaining explicit-only
cost on llama2/mistral/qwen3 that found nothing above ~0.10% of step.
The hand-written custom_vjp reordered the weight-gradient dot's operands
itself and permuted the result back, to work around the sharding barrier
JAX puts on every primitive result under an explicit mesh. Wrapping the
forward dot in an auto_axes region instead removes the barrier for the
length of that one dot, so Shardy re-propagates the operands' shardings
exactly as shard_mode: auto would and XLA performs the fold on its own.

Same mechanism, 114 fewer lines, and gradients are now bit-identical to
the default rule rather than agreeing to rounding. Step times agree with
the custom_vjp to within 0.15 pp on all five models measured both ways.

Outside a mesh there is no barrier to remove, so eval_shape reports no
sharding and the region is skipped.
The flag shipped default-off because its sign looked model-dependent:
worth ~0.9 pp on gemma3 and ~0.16 pp on deepseek, and a 0.06-0.60% cost
on the other six models. Every one of those eight points was measured at
16 layers, which is a scan depth where an unrelated XLA layout pathology
inflates both sharding modes by ~25% and swamps the effect being read.

Re-measured at depths where that pathology does not fire, the flag has
one sign everywhere and the two flags are additive. Across 15
(model, depth) pairs the LM-head flag alone leaves a median +0.463%
penalty against auto, worst +1.038%; with this one on as well the median
is +0.005% and the full range is -0.183% to +0.134%. On the eight-model
matrix at healthy depth, explicit with nothing written out but
shard_mode now reads median +0.000% against auto, worst +0.133%, and
wins outright on gemma-2b (-0.685%) and gemma3-4b (-0.374%).

Also rewrites both flags' descriptions around the auto_axes region that
now implements them.
Walking llama2 from 2 to 24 layers, ns-per-layer is flat at ~3.3 M
except at stack lengths 2, 4, 8, 16 and 24, where it jumps 25-34%. At
those lengths XLA assigns layout {2,0,1} to the scanned parameter stack,
so each dynamic-update-slice writes a degenerate T(1,128)-tiled slice:
8.16 ms of a 36.5 ms step at L8, identical in both sharding modes.
param_scan_axis: 0 recovers it (-23.6% at L8, -24.9% at L16) and it
survives at production geometry.

Every benchmark in the guide before this revision used a stack length of
4, 8 or 16, so several "explicit wins" rows are noise inside a 25%
inflation. The originals are kept and marked; new section 4.9
characterizes the pathology and new section 5.6 replaces section 5.4
with the eight-model matrix re-measured on the shipped defaults at
depths where it does not fire.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant